Skip to content

feat(gateway): removal-refusal signal for the victim, and a node liveness gauge - #1102

Merged
kvinwang merged 3 commits into
nextfrom
feat/gateway-removal-refusal-metrics
Aug 23, 2026
Merged

feat(gateway): removal-refusal signal for the victim, and a node liveness gauge#1102
kvinwang merged 3 commits into
nextfrom
feat/gateway-removal-refusal-metrics

Conversation

@kvinwang

@kvinwang kvinwang commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

#1099 and #1100 have merged; this PR now targets next directly and is the last piece of the removed-node work.

Problem

#1100's lockout refuses a removed node's envelopes with a 403 and a warn line — which observes nothing, because the audience who needs to notice is not reading either node's logs. And independently of removal, the cluster has no direct metric for a member that has simply gone quiet: a long-offline gateway is visible only as an ack watermark that stopped advancing, which takes a PromQL puzzle to alert on.

The refused node is the audience that matters

A mistakenly removed node is exactly the machine whose monitoring is still attached. And it cannot learn of its removal from local state: the removal marker replicates among the surviving members, and the refusals it drives are precisely what keep the victim's copy from ever arriving. The signal has to be read off the door slamming.

So the sync client now keeps a non-success HTTP status as a typed error in the chain (HttpStatusError) — the sender must tell the lockout's 403 apart from a peer that is down or broken, and string-matching a Display line is not a contract — and the sync network, on a 403, counts dstack_gateway_sync_rejected_total. Sync endpoints use 403 for both removal lockouts and app-identity mismatches, so the counter and log report the rejection without claiming which condition caused it. Operators should correlate it with node membership and identity configuration.

The refusing side deliberately stays a warn line, with no counter. It answered no question the victim-side counter does not answer better, and its one residual case — a correctly decommissioned box someone forgot to power off — shows up in the liveness gauge below regardless.

Node liveness, directly

dstack_gateway_node_last_seen_timestamp_seconds{node=...} — the latest time any gateway observed each known node (max across observers, future-dated reports dropped, same data the admin API already serves). Replicated, so aggregate with max(); alert with time() - metric > threshold.

This is also the cost meter for the tombstone GC in #1099: while a member is unreachable, every tombstone written since its last report is pinned by its stale ack watermark, so "how long has node N been gone" is exactly "how much collection is this node holding up".

A node an operator removed leaves the series entirely (its records are deleted). Absence here, plus the 403 counter firing on the machine itself, can indicate the removed-by-mistake picture; an app-identity mismatch is the other possible cause.

Verification

cargo test -p dstack-gateway — 288 passed. Three new tests plus one exposition assertion:

test property
a_refusal_status_is_readable_from_the_error_chain the typed status survives a real TLS request's error chain
only_a_403_reads_as_rejected 400/401/404/500/503 and connection errors must not increment the rejection counter
a_rejection_is_counted_for_the_senders_own_monitoring the sender-side counter moves on 403 and only on 403
(render test) the liveness gauge's exposition line, per node

Copilot AI lite review requested due to automatic review settings August 23, 2026 07:47

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Base automatically changed from fix/gateway-removed-node-lockout to next August 23, 2026 08:08
The removed-node lockout refuses with a 403 and a warn line, which
observes nothing: the audience who needs to notice is not reading
either node's logs.

That audience is the refused node itself. A mistakenly removed node is
exactly the machine whose monitoring is still attached -- and it
cannot learn of its removal from local state, because the refusals are
what keep its copy of the removal marker from ever replicating to it.
The signal has to be read off the door slamming. So the sync client
now keeps a non-success HTTP status as a typed error in the chain
(`HttpStatusError`) -- the sender must tell the lockout's 403 apart
from a peer that is down or broken, and string-matching a Display line
is not a contract -- and the sync network counts
`dstack_gateway_sync_rejected_as_removed_total` and logs the two ways
out (SetNodeUrl re-admission on a surviving gateway, or decommission)
whenever a peer answers 403. Nonzero has exactly one meaning: this
node has been removed and is still trying to sync.

The refusing side stays a warn line, deliberately: the counter that
sat there answered no question the victim-side one does not answer
better, and a correctly decommissioned box that is still powered shows
up in the new liveness gauge's staleness anyway.

Every gateway also exposes
`dstack_gateway_node_last_seen_timestamp_seconds{node=...}` -- the
latest time any gateway observed each known node, max across
observers, future-dated reports dropped. Until now a long-offline
gateway was visible only as an ack watermark that stopped advancing,
which takes a PromQL puzzle to alert on; this is `time() - metric >
threshold`. It also prices what an absent node costs the cluster:
while a member is unreachable, every tombstone written since its last
report is pinned.

Tests pin the two properties that make the victim-side counter
trustworthy -- only a 403 reads as a removal refusal (a peer that is
down, overloaded or broken must not claim this node was removed), and
the typed status survives a real TLS request's error chain -- plus the
exposition line of the liveness gauge.
@kvinwang
kvinwang force-pushed the feat/gateway-removal-refusal-metrics branch from 1ae0666 to fde3338 Compare August 23, 2026 08:18
@kvinwang kvinwang changed the title feat(gateway): count both ends of a removal refusal feat(gateway): removal-refusal signal for the victim, and a node liveness gauge Aug 23, 2026
@kvinwang
kvinwang merged commit 0cd1151 into next Aug 23, 2026
16 checks passed
@kvinwang
kvinwang deleted the feat/gateway-removal-refusal-metrics branch August 23, 2026 13:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants